上一章我們介紹了指標的基本概念,是時候將其進一步延伸了。
還記得我們提過,在C語言中記憶體對程式設計師而言是裸露的,系統會根據對應資料型態分配所占用的位元數(例如:int
至少佔16bits),並將變數值儲存於對應的位置;配合陣列的概念,在儲存一連串同樣資料型態的變數時,我們可以藉由每一陣列值對應的index來隨機存取想要的數值,資料型態所占用的位元數則無需我們計算,只需像陣列一樣給出index即可,我們看一個小例子:
#include <stdio.h>
int main() {
// index : [0][1][2]
int arr [] = {1, 3, 5};
// same as int * ptr = arr;
int * ptr = &arr[0];
ptr = (ptr+2); // index 3-rd element
printf("from pointer : %d\n", *(ptr));
printf("from arr : %d\n", arr[2]);
printf("access arr[1] from pointer : %d\n", *(ptr-1));
}
上一章介紹陣列時提過,arr
本身幾乎等同於一個指向第一個變數值 1
記憶體位址的指標,所以可以直接賦值給ptr
,不過我們也可以先存取到變數值arr[0]
,再複習一下取址符號 &arr[0]
同樣的來取得第一個變數值的記憶體位址。
關鍵在於*(ptr+2)
,想必聰明的讀者已能透過上一章介紹的內容輕易的解讀出它的意義,由於ptr
是指向int
的指標,系統自然知道(ptr+2)
是往後位移2個整數 (在colab上是int
佔32bits,而我們不需顯示的說明是位移64bits的記憶體位址)再取值,而這樣的效果等同於arr[2]
,就可讀性與便利性而言,讀者可能也逐漸理解為何上一章結尾筆者仍建議 "盡可能地使用陣列取代部分指標的工作"。
既然指標進行的是記憶體位址的位移,可以往後(
ptr+2
),自然的也可以往前了(ptr-1
);反觀,陣列定義時即可用正整數的index存取所有範圍的數值,沒有用arr[-1]
嘗試非法存取其他變數範圍的需求,自然地在C語言中arr[-1]
就比較少見,不過ptr-1
仍是很常見的位移操作手段。
就K&R教科書花了許多篇幅講解指標的這個特性,我們可以嘗試再比較一下陣列與指標在其他任務上的操作;例如逐一遍歷陣列中的所有儲存值:
#include<stdio.h>
#define len(arr) sizeof(arr)/sizeof(arr[0])
int main(){
int arr [] = {1, 3, 5, 7, 9, NULL};
int * ptr = arr;
printf("1. array version traversal\n")
// len(arr) - 1 to skip NULL dummy value, that's the endpoint for pointer
for(int idx=0 ; idx < len(arr)-1 ; ++idx)
printf("%d, ", arr[idx]);
printf("\n2. pointer version traversal\n")
for(; *ptr != NULL ; ++ptr)
printf("%d, ", *ptr);
}
可以看到,採用陣列完成仍是較為理想的;或許採用指標在位移和存取數值上看起來比較優雅,但對於何時抵達一連串儲存值的盡頭,指標顯然比較不容易處理這個議題(我們藉由在結尾放置 NULL
值來提供hint);如果陣列長度如同教科書的範例永遠是固定的(例如一年總是12個月、英文字母總是26個、神秘數字總是42),則這個問題就沒有這麼嚴重了。
#include<stdio.h>
int main(){
const int N = 5;
// type conversion to composed literal (int []), just kind of array..
int * ptr = (int []){1, 3, 5, 7, 9};
for(int idx=0 ; idx < N ; ++idx, ++ptr){
--(*ptr); // you can remove ( ), but why not keep it ~
printf("%d, ", *ptr);
}
}
既然指標指向的陣列長度是固定的,我們就可以不依賴end point值(NULL
)做結尾判斷,但需另外設置一個counter(idx
變數)紀錄已遍歷的數值個數;這類分化同一事務的作法是否恰當(計數器自己紀錄自己的、指標自己位移自己的),則看交由各位讀者自行判斷了(至少筆者不喜歡www)。
值得一提的是,在例子中筆者帶出了一個簡化陣列賦值給指標的作法;以往我們會先定義一個陣列變數,在將其位址賦值給指標
ptr
,而這一切可以藉由複合常量(composed literal)的支援進行簡化,它的聲明如同陣列一樣,我們可以對初始化列表{1, 3, 5, 7, 9}
使用(int [])
來進行顯示的轉型;背地裡,系統會在記憶體產生一個匿名的陣列(因為是由初使化列表轉型、尚未賦值給任何變數),接著宣告一個指向此匿名陣列的指標ptr
;而我們也可以自由地更改裡面的內容(範例中我們將每個值減1),即使它被稱為常量。
介紹了指標的位移和陣列的優勢,我們可以藉由對兩者概念的理解,適當的將位移與陣列結合,輕易實現一些trick:
#include<stdio.h>
int main(){
int arr [ ] = {1, 3, 5, 7, 9};
printf("sizeof arr : %d vs. sizeof int : %d\n", sizeof(arr), sizeof(arr[0]));
// not safty : (*(&arr + 1))[-1]), *(*(&a + 1) - 1)
int* arr_end = (int *)(&a + 1);
printf("The last element of arr : %d", arr_end[-1]);
}
我們想要存取陣列的最後一個值最好的作法當然是arr[sizeof(arr)/sizeof(arr[0])]
,雖然這個做法在沒有採用巨集時似乎不太優雅,但sizeof
在arr
和arr[0]
的差異卻給了我們一個轉機! 我們知道sizeof(arr)
的尺寸會返回整個陣列的長度,因此當我們對arr
的取址並往後位移1格時,事實上(&arr+1)
已經讓我們移動到陣列末尾的位址,此時將此位址轉型為指向資料型態 (int*
),而非指向陣列的指針時,根據sizeof(arr[0])
的尺寸則會返回一個整數資料所占用的長度,我們對位於尾端的位址往前 *(arr_end-1)
移動一個整數,剛好就是陣列最後元素的起始位址,這巧妙的trick很多時候也被寫成一行外星密碼(*(&arr + 1))[-1]
或*(*(&a + 1) - 1)
,在理解的同時也請注意安全。
巧妙的trick或許有用,卻是經不起時間考驗的;這句話不一定是指著技術本身有問題,而是將人為因素考量進去的經驗。不過在這裡,
(*(&arr + 1))[-1]
或*(*(&a + 1) - 1)
確實是技術上不安全的! 你可以儲存一個陣列尾端的指標*(&a + 1)
,但對這個位址取值 (解參考, deference)的行為時常是未定義的,也就是運氣好時它可能成功(例如陣列後面剛好不是程式非法存取的記憶體區段),運氣不好時則會莫名的失敗。
然而,陣列也有不適用的時候;將陣列作為參數傳遞至函數時,他會退化為一般的指標!!
#include <stdio.h>
#define len(arr) sizeof(arr)/sizeof(arr[0])
void array_decay(int arr []){
printf("prnt len of arr : %ld", len(arr));
}
int main()
{
int arr [] = {1, 3, 5, 7, 9};
printf("len of arr : %ld\n", len(arr));
array_decay(arr);
return 0;
}
從運行結果可以看出,原本陣列arr
占用了20 bytes(5個int
)的空間,將陣列傳遞至函數時則退化成普通的指針,指針的大小只佔 8 bytes(這個數值還會隨著具體的機器而不同),既然指針的大小已經與陣列不符,巨集後續採用sizeof
進行的運算就全盤皆錯了。要在函數中得知陣列的實際長度,只能在傳入陣列前先進行計算,並將長度作為整數參數一併傳入函數了。
關於為何陣列傳入函數會退化成指標,請參閱傳送們
回顧指標的概念,指標聲明了一個儲存其他變數記憶體位置的變數,既然指標自身也是變數,它就應該也有記憶體位置;而巧妙的是我們能用另一個指標來儲存這個指標的記憶體位置,形成了指標的指標(多重指標)。
除了上ㄧ章提到儲存字串陣列時會使用多重指標外,另一個典型的例子就是多維陣列了:
#include <stdio.h>
#include <stdlib.h>
double** make_matrix(int h, int w, double init_value){
double **mtx = calloc(h, sizeof(double*));
for(int idx=0; idx<h ; ++idx){
mtx[idx] = calloc(w, sizeof(double));
for(int jdx=0 ; jdx<w ; ++jdx)
mtx[idx][jdx] = init_value;
}
return mtx;
}
int main()
{
int h=3, w=2;
double** mtx = make_matrix(h, w, 3.14);
for(int idx=0; idx<h ; ++idx){
for(int jdx=0 ; jdx<w ; ++jdx)
printf("%f ", mtx[idx][jdx]);
printf("\n");
}
// forgot to free(mtx)...
}
我們撰寫了一個函數make_matrix
來創建矩陣,如上ㄧ章所提,回傳ㄧ個在函數內創建的陣列就需運用calloc
自行分配與管理記憶體,這樣主程式main
所接收的多維陣列才能繼續使用。
malloc
是最常聽聞的內建函數,用於輔助我們分配ㄧ個匿名的記憶體區塊,它的函數聲明為 void *malloc(size_t size)
,要求你直接計算完總佔用的尺寸(nitems*sizeof(items_type)
)再傳遞參數給它,而它會返回ㄧ個void*
指標指向分配的匿名記憶體區塊;基於CH3提到的隱式轉換之ㄧ,我們只需聲明適當的指標變數來接收void*
指標就會自動轉型。
然而malloc
的缺點在於它所分配的匿名記憶體區塊並不會初始化,所以取得後還需要按需求初始化裡面的數值;雖然這並不構成麻煩 (範例子中我們也是直接根據給定的init_value
初始化),不過calloc
卻能更貼心的幫我們先將匿名記憶體區塊的值初始化為0或NULL
,因此大部分仍推薦採用calloc
函數;此外它的函數介面也更清晰 void *calloc(size_t nitems, size_t size)
,它將要分配幾個值(nitems
)與分配的資料型態所佔位元(size
, 或是sizeof(items_type)
)相區分。
理解分配的函數後,多重指標也只是依序地要求分配空間,或指向已創建的陣列;這邊我們創建的對象是矩陣,因為只有兩個維度,首先將h
(height)個double
指標賦值給雙重指標double** mtx
,爾後再用1個for迴圈遍歷這h
個指標變數,對於每個指標變數,我們再同樣分配擁有w
(width)個值的記憶體區段給它們即可;為便於同時初始化數值,筆者額外用了第二個for迴圈將w
個值按需求初始化;講解到這裡可以說是有點畫蛇添足了,聰明的讀者只要熟悉前面指標的概念,上面的程式碼恐怕只是小試身手的層級了 ~
很高興看到C語言親和的ㄧ面,遺憾的是有時候我們要對ㄧ個不確定維度的多維陣列進行操作(多維指標),這時我們也就不能像上述範例,預先撰寫for迴圈了;這時我們回到開頭的ㄧ個概念:"記憶體對我們是裸露的",因此實務上比較採用直接宣告ㄧ個陣列,陣列直接包含所有維度的值,而將各維度的資訊(例如每ㄧ個維度有多少元素)當作參數一併傳給函數;活用開頭介紹的位移來靈活地存取、操作不同維度的資訊。
然而,在建構更加嚴謹、大型的程式,我們寧可建議讀者直接採用ㄧ些知名的開源庫,例如(GNU Scientific Lib, GSL
)就提供了完整測試、多應用場景、良好的介面設計等性質的多維陣列物件(2維即為矩陣)、常用的數值計算函數,讓我們不需重複造輪子!
或許你曾和筆者ㄧ樣,覺得只要寫python的script-kid才會call別人的函數庫,都已經寫到C語言了還要用別人的lib嗎? 這樣不夠hack喔! 對於累積經驗等自行開發的專案,自行撰寫程式碼,並進行對應的測試是ㄧ個很好的方向;不過大型程式常考驗的更是整體的軟體架構、軟體品質的管控、減少技術的負債等,這些不是我們需要浪費生命去踩雷的,相信其他世界上的co-worker,一起建構更好的軟體,我想才是更有意義的 ~
預告 CH5:ㄧ些開頭沒介紹的入門概念?!